Skip to content

Augment BooleanAnd falsey and BooleanOr truthy type narrowing when left and right conditions narrow different expression keys#5595

Open
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-58oa332
Open

Augment BooleanAnd falsey and BooleanOr truthy type narrowing when left and right conditions narrow different expression keys#5595
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-58oa332

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When isset($test['hi']) && is_string($test['hi']) was used in a combined && condition with an early return, PHPStan failed to narrow the array union type in the falsey branch. Splitting the condition into nested if blocks worked correctly. The fix recovers the lost type narrowing for both BooleanAnd falsey and the De Morgan dual BooleanOr truthy.

Changes

  • Added augmentDisjunctionTypes() method to src/Analyser/TypeSpecifier.php that computes both disjunction-path filtered scopes and checks if candidate expressions are narrowed in both paths. If so, it creates a sureType with the union of the narrowed types.
  • Called this method in the BooleanAnd falsey branch (after the existing intersectWith) using left-falsey and right-falsey-given-left-truthy scopes.
  • Called this method in the BooleanOr truthy branch (after the existing intersectWith and augmentBooleanOrTruthyWithConditionalHolders) using left-truthy and right-truthy-given-left-falsey scopes.
  • Updated tests/PHPStan/Analyser/data/type-specifying-extensions-2-false.php and type-specifying-extensions-2-null.php: $foo is now correctly string instead of string|null because the test extension fires in all contexts and both disjunction paths narrow it.
  • Updated tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php (testBug3632): after progressive elimination by early returns, $a is now correctly narrowed to null and $b to NiceClass, producing an additional true-positive instanceof error at line 36.
  • Updated tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php (testBug11903): PHPDoc tip removed from line 21's error because the narrowing is now from control flow rather than PHPDoc types.

Root cause

In TypeSpecifier::specifyTypesInCondition, the falsey branch of BooleanAnd (and truthy branch of BooleanOr) uses SpecifiedTypes::intersectWith() to combine type assertions from the two operands. intersectWith only keeps expression keys present in both operand sets. When the left condition narrows $test (the array variable) and the right condition narrows $test['hi'] (an array offset), they have different expression keys, so intersectWith drops both — resulting in no narrowing at all.

The fix adds a post-intersection augmentation step that computes the two actual filtered scopes (one for each disjunction path) and checks if both paths narrow the same expression. If so, the union of the narrowed types is added as a sureType.

Analogous cases probed

  • BooleanOr truthy (De Morgan dual): same information loss pattern, fixed with the same augmentDisjunctionTypes method using truthy scopes instead of falsey scopes. Test added.
  • Other type-check functions: is_array, is_int, is_float, is_bool all tested in combination with isset — all pass with the fix.
  • array_key_exists: tested as an alternative to isset — passes.
  • Conditional expression holders path (via augmentBooleanOrTruthyWithConditionalHolders): already existed for BooleanOr truthy but only handles candidates from pre-existing conditional holders in the scope, not from the current condition's sureTypes. The new augmentation complements it.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14566.php: regression test for the reported bug with isset && is_string on array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}}, plus:
    • Nested-if baseline (already worked)
    • Combined && with early return (the reported bug)
    • Combined && with offset assignment (from the issue's playground)
    • || dual case (!isset || !is_string)
    • is_array, is_int, is_float, is_bool variants
    • array_key_exists variant

Fixes phpstan/phpstan#14566

…n left and right conditions narrow different expression keys

- In TypeSpecifier::specifyTypesInCondition for BooleanAnd falsey context,
  the intersection of normalized left and right SpecifiedTypes drops all
  narrowing when the two sides operate on different expression keys
  (e.g. $test vs $test['hi']). Add augmentDisjunctionTypes() to recover
  narrowing by computing both disjunction-path scopes and unioning the
  narrowed types for any expression narrowed in both paths.
- Apply the same augmentation for BooleanOr truthy context, which is
  the De Morgan dual and has the same information loss.
- Update four existing tests whose assertions reflected the old
  incomplete narrowing: type-specifying-extensions-2-{false,null}.php
  (extension fires in all contexts, both paths now narrow correctly),
  bug-3632 (additional true-positive instanceof error detected),
  bug-11903 (PHPDoc tip removed because narrowing is now from control
  flow).
- Add regression tests covering the reported case (isset && is_string
  on array union), the || dual, and parallel type checks (is_array,
  is_int, is_float, is_bool, array_key_exists).
Comment thread src/Analyser/TypeSpecifier.php Outdated
if ($context->true()) {
$types = $leftTypes->unionWith($rightTypes);
} else {
$leftNorm = $leftTypes->normalize($scope);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename $leftNorm and rightNorm to leftNormalized and rightNormalized

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review feedback addressed and pushed:

  1. Renamed $leftNorm/$rightNorm to $leftNormalized/$rightNormalized at both call sites and in the method parameters.
  2. Extracted $types->getSureTypes() into $existingSureTypes variable before the foreach loop.
  3. Renamed $origType to $originalType.
  4. Early-continue on left narrowing — split the combined if (!$leftNarrowed || !$rightNarrowed) into two separate checks so we skip computing $rightType narrowing when left isn't narrowed.

The reviewer's question about whether overriding $candidateExprs entries from the right loop is an issue: it isn't, because we only use the Expr node for scope lookups (not the type from sureTypes), and both loops map the same $exprString key to equivalent expression nodes. The actual type narrowing is computed from the filtered scopes, not from the sureTypes directly.

$candidateExprs[$exprString] = $exprNode;
}
foreach ($rightNorm->getSureTypes() as $exprString => [$exprNode, $type]) {
$candidateExprs[$exprString] = $exprNode;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't an issue to risk to override a sureType for the leftNormalized foreach loop ?

Comment thread src/Analyser/TypeSpecifier.php Outdated
}

foreach ($candidateExprs as $exprString => $targetExpr) {
if (isset($types->getSureTypes()[$exprString])) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create a variable for $types->getSureTypes() before the foreach loop.

Comment thread src/Analyser/TypeSpecifier.php Outdated
continue;
}

$origType = $scope->getType($targetExpr);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename this originalType


$origType = $scope->getType($targetExpr);
$leftType = $leftFilteredScope->getType($targetExpr);
$rightType = $rightFilteredScope->getType($targetExpr);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could already continue if !$leftNarrowed without having to compue rightNarrowed

- Rename $leftNorm/$rightNorm to $leftNormalized/$rightNormalized
- Rename $origType to $originalType
- Extract $types->getSureTypes() into $existingSureTypes before the loop
- Early-continue on left narrowing check before computing right narrowing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

assertType('string|null', $foo);
assertType('string', $foo);
assertType('int|null', $bar);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why $foo is refined but not $bar ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Here's the answer to the reviewer's question:

Why $foo is refined but not $bar:

In A && B, when the overall condition is false, there are two disjunction paths:

  1. Left-false: assertString($foo) was evaluated and returned false
  2. Right-false (given left truthy): assertString($foo) returned true, then assertInt($bar) returned false

The key asymmetry is that assertString($foo) (left operand) is always evaluated regardless of which path is taken, while assertInt($bar) (right operand) is only evaluated in path 2 due to short-circuit evaluation.

Since the test extension fires in both truthy and falsey contexts (nullContext: false!$context->null()):

  • $foo is narrowed to string in both paths → union = string
  • $bar is narrowed to int only in path 2; in path 1 it stays int|null (assertInt was never called) → augmentDisjunctionTypes correctly skips it because $leftType->equals($originalType)

I added inline comments to both test files explaining this asymmetry for future readers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants